Skip to content

Add Hydra micropayments (gateway↔end user)#451

Open
michalrus wants to merge 31 commits intofeat/hydra-paymentsfrom
feat/hydra-payments-2
Open

Add Hydra micropayments (gateway↔end user)#451
michalrus wants to merge 31 commits intofeat/hydra-paymentsfrom
feat/hydra-payments-2

Conversation

@michalrus
Copy link
Copy Markdown
Member

@michalrus michalrus commented Feb 9, 2026

Resolves #278

Context

@michalrus michalrus self-assigned this Feb 9, 2026
@michalrus michalrus added the enhancement New feature or request label Feb 9, 2026
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Feb 9, 2026

Deploying blockfrost-platform with  Cloudflare Pages  Cloudflare Pages

Latest commit: 42da948
Status: ✅  Deploy successful!
Preview URL: https://12949fa4.blockfrost-platform.pages.dev
Branch Preview URL: https://feat-hydra-payments-2.blockfrost-platform.pages.dev

View logs

@michalrus michalrus force-pushed the feat/hydra-payments-2 branch from e057bc7 to 9403975 Compare February 19, 2026 22:11
@michalrus michalrus force-pushed the feat/hydra-payments-2 branch from 9393aee to 473bb17 Compare February 19, 2026 23:22
@michalrus michalrus force-pushed the feat/hydra-payments-2 branch from 98adc65 to e394f56 Compare February 23, 2026 09:21
This was referenced Feb 23, 2026
@michalrus michalrus force-pushed the feat/hydra-payments-2 branch from 366d717 to f5665c2 Compare March 13, 2026 14:01
@michalrus michalrus changed the base branch from main to feat/hydra-payments March 13, 2026 14:02
@vladimirvolek vladimirvolek requested a review from Copilot March 13, 2026 14:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an SDK Bridge (local HTTP proxy ↔ Gateway WebSocket) and a new Hydra micropayment channel flow between Gateway and end user, alongside Gateway-side WebSocket support for the bridge and Hydra session management.

Changes:

  • Introduces new crates/sdk_bridge binary that proxies local HTTP requests to the Gateway over /sdk/ws and runs a local hydra-node to pay per request.
  • Adds a new Gateway /sdk/ws WebSocket route plus hydra_server_bridge to manage customer Hydra controllers and credit tracking.
  • Refactors Nix devshell Hydra scripts TX-id env var setup to use a shared internal helper.

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
nix/devshells.nix Uses internal.hydraScriptsEnvVars to populate Hydra scripts TX-id env vars.
crates/sdk_bridge/Cargo.toml New SDK bridge crate dependencies and lint config.
crates/sdk_bridge/src/main.rs New bridge binary entrypoint wiring config, WS client, and HTTP proxy.
crates/sdk_bridge/src/config.rs CLI args + normalization of Gateway WS URL.
crates/sdk_bridge/src/http_proxy.rs Local Axum HTTP server that converts HTTP↔JSON-over-WS and enforces prepaid credits.
crates/sdk_bridge/src/ws_client.rs WS connection loop to Gateway, request/response correlation, pinging, and hydra KEx/tunnel forwarding.
crates/sdk_bridge/src/hydra_client/mod.rs Bridge-side Hydra controller: ceremony, commit, prepay, microtransactions, credit polling.
crates/sdk_bridge/src/hydra_client/verifications.rs Bridge-side Cardano/Hydra verification helpers and subprocess utilities.
crates/sdk_bridge/src/protocol.rs Shared JSON protocol types for bridge↔gateway messages.
crates/sdk_bridge/src/types.rs Network enum + magic numbers used by hydra/cardano-cli invocations.
crates/sdk_bridge/src/find_libexec.rs Helper to locate hydra-node / cardano-cli binaries.
crates/gateway/src/main.rs Registers /sdk/ws route and initializes hydra_bridge manager.
crates/gateway/src/lib.rs Exposes new hydra_server_bridge and sdk_bridge_ws modules.
crates/gateway/src/sdk_bridge_ws.rs Implements Gateway-side bridge WebSocket server + in-memory HTTP dispatch and hydra credit gating.
crates/gateway/src/hydra_server_bridge/mod.rs Gateway-side Hydra manager/controller for bridge customers, incl. KEx and credit monitoring/fanout.
crates/gateway/src/hydra_server_bridge/verifications.rs Gateway-side Cardano/Hydra helpers used by bridge customer controllers.
crates/gateway/src/config.rs Adds [hydra_bridge] configuration section to gateway config model.
crates/gateway/config/development.toml Adds sample [hydra_bridge] config alongside existing [hydra_platform].
crates/gateway/Cargo.toml Adds tower dependency for ServiceExt::oneshot usage.
Cargo.toml Adds crates/sdk_bridge to workspace members.
Cargo.lock Locks new crate and dependency additions (notably tower).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +199 to +204
let config_dir = dirs::config_dir()
.expect("Could not determine config directory")
.join("blockfrost-sdk-bridge")
.join("hydra")
.join(config.network.as_str())
.join("_default");
Comment on lines +51 to +53
if (200..400).contains(&response.code) {
state.bridge.hydra().account_one_request().await;
}
Comment on lines +500 to +504
JsonResponse {
id: request_id_,
code: code.into(),
header: vec![],
body_base64: err,
Comment on lines +450 to +456
fn error_response(request_id: RequestId, code: u16, msg: String) -> JsonResponse {
JsonResponse {
id: request_id,
code,
header: vec![],
body_base64: msg,
}
Comment on lines +381 to +418
while let Some(Ok(msg)) = sock_rx.next().await {
match msg {
Message::Text(text) => {
match serde_json::from_str::<BridgeMessage>(&text) {
Ok(msg) => {
if event_tx
.send(BridgeEvent::NewBridgeMessage(msg))
.await
.is_err()
{
break;
}
},
Err(err) => warn!(
"sdk-bridge-ws: received unparsable text message: {:?}: {:?}",
text, err,
),
};
},
Message::Binary(bin) => {
warn!(
"sdk-bridge-ws: received unexpected binary message: {:?}",
hex::encode(bin),
);
},
Message::Close(frame) => {
warn!(
"sdk-bridge-ws: bridge disconnected (CloseFrame: {:?})",
frame,
);
let _ignored_failure: Result<_, _> = event_tx
.send(BridgeEvent::Finish("bridge disconnected".to_string()))
.await;
break;
},
Message::Ping(_) | Message::Pong(_) => {},
}
}
Comment on lines +39 to +54
let minimal_commit: f64 = 1.01
* (config.lovelace_per_request
* config.requests_per_microtransaction
* config.microtransactions_per_fanout
+ MIN_LOVELACE_PER_TRANSACTION) as f64
/ 1_000_000.0;
if config.commit_ada < minimal_commit {
Err(anyhow!(
"hydras-manager: Please make sure that configured commit_ada ≥ lovelace_per_request * requests_per_microtransaction * microtransactions_per_fanout + {}.",
MIN_LOVELACE_PER_TRANSACTION as f64 / 1_000_000.0
))?
}

let microtransaction_lovelace: u64 =
config.lovelace_per_request * config.requests_per_microtransaction;
if microtransaction_lovelace < MIN_LOVELACE_PER_TRANSACTION {
Comment on lines +168 to +169
// Schedule the first `PingTick` immediately, otherwise we won’t start
// checking for ping timeout:
Comment on lines +282 to +293
BridgeEvent::NewRequest(req) => {
let request_id = req.request.id.clone();
inflight
.lock()
.await
.insert(request_id.clone(), req.respond_to);
if let Err(err) =
send_json_msg(&socket_tx, &BridgeMessage::Request(req.request)).await
{
loop_error = Err(err);
break 'event_loop;
}
Comment on lines +30 to +48
// This is the most important one for relocatable directories (that keep the initial
// structure) on Windows, Linux, macOS:
let current_exe_dir: Option<PathBuf> =
std::fs::canonicalize(env::current_exe().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?
.parent()
.map(|a| a.to_path_buf().join(exe_name));

// Similar, but accounts for the `nix-bundle-exe` structure on Linux:
let current_package_dir: Option<PathBuf> = current_exe_dir
.clone()
.and_then(|a| a.parent().map(PathBuf::from))
.and_then(|a| a.parent().map(PathBuf::from));

let cargo_target_dir: Option<PathBuf> = env::var("CARGO_MANIFEST_DIR")
.ok()
.map(|root| PathBuf::from(root).join("target/testgen-hs/extracted/testgen-hs"));

let docker_path: Option<PathBuf> = Some(PathBuf::from(format!("/app/{exe_name}")));
Comment on lines +456 to +462
fn error_response(request_id: RequestId, code: StatusCode, why: String) -> JsonResponse {
JsonResponse {
id: request_id,
code: code.as_u16(),
header: vec![],
body_base64: why,
}
Copy link
Copy Markdown
Collaborator

@ginnun ginnun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General nitpicking:
Significant code duplication between gateway and bridge — verifications.rs, find_libexec.rs, KeyExchangeRequest/KeyExchangeResponse, and JsonRequest/JsonResponse protocol types are all duplicated nearly verbatim. Any drift would cause silent wire-format incompatibilities. Consider DRY if preferred by you and possible.

Personally 3 or more repetitions is trigger for DRY.

WaitForIdleAfterClose,
}

fn mk_config_dir(network: &Network, customer_machine_id: &str) -> Result<PathBuf> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, is this dangerous? What happens if my payload is:

    HydraKExRequest: {
      machine_id: "../../../../tmp/pwned-by-path-traversal",
      platform_cardano_vkey: {},
      platform_hydra_vkey: {},
      accepted_platform_h2h_port: null,
    },

If a valid scenario, we should validate machine_id to contain only hex chars.


let sdk_state = sdk_bridge_ws::SdkBridgeState::new(base_router.clone(), hydras_bridge_manager);

let app = base_router
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /sdk/ws WebSocket endpoint has no authentication — any client can connect and start consuming Hydra resources (key exchange, hydra-node spawning).

Do sdk/ws need auth? Is authentication planned for a follow-up, or intentionally deferred?

Pong(u64),
}

async fn run_ws_loop(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the WebSocket connection fails or drops, run_ws_loop exits permanently with no reconnection logic. The bridge's HTTP proxy stays alive but returns 503 for every subsequent request — a dead endpoint with no recovery path.

For inflight requests, the cleanup at the end of run_ws_loop correctly sends 503 via error_response. But no new connections are attempted.

already_exists,
&state.hydras,
&req.accepted_platform_h2h_port,
initial_hydra_kex.take(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking:
.take() is evaluated eagerly before the match arms, so the KEx state would be consumed even if an error arm were reached. In the current code this is harmless. The error arms can't fire when initial_hydra_kex is Some — but moving .take() inside the arm that binds initial_kex would be more robust against future refactors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hopper: Hydra payment channel between gateway and consumer

3 participants